///////////////////////////////////////////////////////
// Code author: Martin Lapierre, http://devinstinct.com
///////////////////////////////////////////////////////

using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Configuration;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Security;
using System.Web.Configuration;

namespace SubSonic
{
    /// <summary>
    /// Resolves access to configuration files for ASP.NET, EXEs and DLLs.
    /// </summary>
    public class ConfigurationResolver
    {
        #region Fields

        /// <summary>
        /// The assembly configuration file name.
        /// </summary>
        private string _assemblyConfigFile = null;

        /// <summary>
        /// The configuration file overrides.
        /// </summary>
        private string[] _configFileOverrides = null;

        /// <summary>
        /// The excluded files from mapped lookup.
        /// </summary>
        /// <remarks>
        /// Some files may be unavailable due to security settings (trust, permissions).
        /// The resolver ignores these files and falls back to the next accessible file.
        /// </remarks>
        private Dictionary<string, Exception> _excludedFiles = new Dictionary<string, Exception>();

        #endregion Fields


        #region Constructors

        /// <summary>
        /// Constructor.
        /// </summary>
        /// <remarks>
        /// This constructor is not intended to be used directly: 
        /// use the ConfigurationProvider class instead.
        /// Note that an assembly configuration file is not supported for Web application projects; 
        /// it is supported however for DLLs referenced by web applications.
        /// </remarks>
        internal ConfigurationResolver(Assembly assembly)
        {
            _assemblyConfigFile = GetAssemblyConfigFileName(assembly);
        }

        #endregion Constructors


        #region Properties

        /// <summary>
        /// Returns the assembly configuration file that may be used by this instance.
        /// </summary>
        public string AssemblyConfigurationFile
        {
            get { return _assemblyConfigFile; }
        }

        /// <summary>
        /// Returns the default configuration file that may be used by this instance.
        /// </summary>
        public string DefaultConfigurationFile
        {
            get { return AppDomain.CurrentDomain.SetupInformation.ConfigurationFile; }
        }

        /// <summary>
        /// Gets or sets overrides for configuration files.
        /// </summary>
        /// <remarks>
        /// Can be set at runtime to provide configuration files to 
        /// be used instead than the assembly and default config files.
        /// </remarks>
        public string[] ConfigFileOverrides
        {
            get { return _configFileOverrides; }
            set
            {
                if (value == null)
                    _configFileOverrides = null;
                else
                    InitConfigFileOverrides(value);
            }
        }

        /// <summary>
        /// Returns true if the current instance has configuration file overrides, false otherwise.
        /// </summary>
        public bool HasOverrides
        {
            get { return _configFileOverrides != null; }
        }

        /// <summary>
        /// Returns true if the currently running application is a Web application, false otherwise.
        /// </summary>
        /// <remarks>
        /// An application is set as a Web application if its configuration file is Web.config.
        /// </remarks>
        public bool IsRunningWebApp
        {
            get
            {
                return Path.GetFileName(DefaultConfigurationFile).ToLower().CompareTo("web.config") == 0;
            }
        }

        /// <summary>
        /// Gets the ConnectionStringsSection data for the current application's default configuration.
        /// </summary>
        /// <remarks>
        /// The method uses the configuration file overrides, if set.
        /// Otherwise, it tries to use the assembly configuration file and
        /// falls back to app.config or web.config if the configuration information is not found.
        /// </remarks>
        public ConnectionStringSettingsCollection ConnectionStrings
        {
            get
            {
                ConnectionStringSettingsCollection connectionStrings = null;

                try
                {
                    Configuration cfg;

                    if (_configFileOverrides != null) // Use configuration overrides.
                        foreach (string fileName in _configFileOverrides)
                        {
                            cfg = OpenMappedConfiguration(fileName);
                            if (cfg != null)
                            {
                                connectionStrings = cfg.ConnectionStrings.ConnectionStrings;
                                if (cfg.ConnectionStrings.ElementInformation.IsPresent)
                                    break;
                            }
                        }
                    else // Use runtime configuration files.
                    {
                        if (_assemblyConfigFile != null)
                        {
                            cfg = OpenMappedConfiguration(_assemblyConfigFile);
                            if (cfg != null)
                            {
                                if (cfg.ConnectionStrings.ElementInformation.IsPresent)
                                    connectionStrings = cfg.ConnectionStrings.ConnectionStrings;
                            }
                        }

                        if (connectionStrings == null) // Fall back to default config file. 
                        {
                            if (IsRunningWebApp)
                                connectionStrings = WebConfigurationManager.ConnectionStrings; // Web.config
                            else
                                connectionStrings = ConfigurationManager.ConnectionStrings; // App.config (Assembly.exe.config)
                        }
                    }
                }
                catch (Exception exception)
                {
                    throw new ConfigurationErrorsException("Configuration error.", exception);
                }

                if (connectionStrings == null)
                    throw new ConfigurationErrorsException("Connection strings not found.");

                return connectionStrings;
            }
        }

        /// <summary>
        /// Gets the AppSettingsSection data for the current application's default configuration. 
        /// </summary>
        /// <remarks>
        /// The method uses the configuration file overrides, if set.
        /// Otherwise, it tries to use the assembly configuration file and
        /// falls back to app.config or web.config if the configuration information is not found.
        /// </remarks>
        public NameValueCollection AppSettings
        {
            get
            {
                NameValueCollection appSettings = null;

                try
                {
                    Configuration cfg;

                    if (_configFileOverrides != null) // Use configuration overrides.
                        foreach (string fileName in _configFileOverrides)
                        {
                            cfg = OpenMappedConfiguration(fileName);
                            if (cfg != null)
                            {
                                // TODO: perf improvement; use caching until cfg changed.
                                appSettings = new NameValueCollection();
                                foreach (KeyValueConfigurationElement element in cfg.AppSettings.Settings)
                                    appSettings.Add(element.Key, element.Value);

                                if (cfg.AppSettings.ElementInformation.IsPresent)
                                    break;
                            }
                        }
                    else // Use runtime configuration files.
                    {
                        if (_assemblyConfigFile != null)
                        {
                            cfg = OpenMappedConfiguration(_assemblyConfigFile);
                            if (cfg != null)
                            {
                                if (cfg.AppSettings.ElementInformation.IsPresent)
                                {
                                    // TODO: perf improvement; use caching until cfg changed.
                                    appSettings = new NameValueCollection();
                                    foreach (KeyValueConfigurationElement element in cfg.AppSettings.Settings)
                                        appSettings.Add(element.Key, element.Value);
                                }
                            }
                        }

                        if (appSettings == null) // Fall back to default config file. 
                        {
                            if (IsRunningWebApp)
                                appSettings = WebConfigurationManager.AppSettings; // Web.config
                            else
                                appSettings = ConfigurationManager.AppSettings; // App.config (Assembly.exe.config)
                        }
                    }
                }
                catch (Exception exception)
                {
                    throw new ConfigurationErrorsException("Configuration error.", exception);
                }

                if (appSettings == null)
                    return new NameValueCollection();

                return appSettings;
            }
        }

        #endregion Properties


        #region Methods

        /// <summary>
        /// Retrieves a specified configuration section for the current application's default configuration. 
        /// </summary>
        /// <remarks>
        /// The method uses the configuration file overrides, if set.
        /// Otherwise, it tries to use the assembly configuration file and
        /// falls back to app.config or web.config if the configuration information is not found.
        /// </remarks>
        /// <typeparam name="TSection">The type of the section to retrieve.</typeparam>
        /// <param name="sectionName">The configuration section path and name.</param>
        /// <returns>
        /// The specified ConfigurationSection object. 
        /// Throws a ConfigurationErrorsException if the section does not exist.
        /// </returns>
        public TSection GetSection<TSection>(string sectionName) where TSection : ConfigurationSection
        {
            // Validate parameters.
            if (string.IsNullOrEmpty(sectionName))
                throw new ArgumentNullException("sectionName");

            TSection section = null;

            try
            {
                Configuration cfg;
                

                if (_configFileOverrides != null) // Use configuration overrides.
                    foreach (string fileName in _configFileOverrides)
                    {
                        cfg = OpenMappedConfiguration(fileName);
                        if (cfg != null)
                        {
                            section = (TSection)cfg.GetSection(sectionName); // Throws if invalid section type.
                            if (section != null && section.ElementInformation.IsPresent)
                                break;
                        }
                    }
                else // Use runtime configuration files.
                {
                    if (_assemblyConfigFile != null)
                    {
                        cfg = OpenMappedConfiguration(_assemblyConfigFile);
                        if (cfg != null)
                        {
                            TSection candidateSection = (TSection)cfg.GetSection(sectionName); // Throws if invalid section type.
                            if (candidateSection != null && candidateSection.ElementInformation.IsPresent)
                                section = candidateSection;
                        }
                    }

                    if (section == null) // Fall back to default config file. 
                    {
                        // Throws if invalid section type.
                        // Use (Web)ConfigurationManager.GetSection and not Configuration.GetSection 
                        // (more performant as the first ones use caching - see help).
                        if (IsRunningWebApp)
                            section = (TSection)WebConfigurationManager.GetSection(sectionName); // Web.config
                        else
                            section = (TSection)ConfigurationManager.GetSection(sectionName); // App.config (Assembly.exe.config)
                    }
                }
            }
            catch (Exception exception)
            {
                throw new ConfigurationErrorsException("Configuration error.", exception);
            }

            // TODO: maybe should return null; this is the default .NET behavior. But here it's safer.
            if (section == null)
                throw new ConfigurationErrorsException(string.Format("Section '{0}' not found.", sectionName));

            return section;
        }

        /// <summary>
        /// Finds a project configuration files from a specified path.
        /// </summary>
        /// <remarks>
        /// The method starts by searching the given path, then goes up the directory hierarchy until 
        /// it finds the configuration files. It stops looking if a project, solution or the root
        /// directory is reached.
        /// </remarks>
        /// <param name="path">
        /// A file path or a directory path. 
        /// A directory path must end with Path.DirectorySeparatorChar.
        /// </param>
        /// <returns>The configuration files found.</returns>
        public string[] FindProjectConfigFiles(string path)
        {
            List<string> configFiles = new List<string>();

            string targetFile = null;
            string[] filesFound = null;
            string currentDirectory = Path.GetDirectoryName(path);

            // Format directory correctly.
            if (currentDirectory != string.Empty && !currentDirectory.EndsWith(Path.DirectorySeparatorChar.ToString()))
                currentDirectory = string.Format("{0}{1}", currentDirectory, Path.DirectorySeparatorChar);

            do
            {
                // First look for the assembly's configuration file, 
                // which has priority over Web.config and App.config
                // (that is, must be the first in the list).
                if (_assemblyConfigFile != null)
                {
                    targetFile = string.Format("{0}{1}", currentDirectory, Path.GetFileName(_assemblyConfigFile));
                    if (File.Exists(targetFile))
                        configFiles.Add(targetFile);
                }

                // Then look for Web.config
                targetFile = string.Format("{0}{1}", currentDirectory, "Web.config");
                if (File.Exists(targetFile))
                    configFiles.Add(targetFile);

                // Then look for App.config
                targetFile = string.Format("{0}{1}", currentDirectory, "App.config");
                if (File.Exists(targetFile))
                    configFiles.Add(targetFile);

                // If at least one config file has been found, stop.
                if (configFiles.Count != 0)
                    break;

                // If we find a project file, stop.
                filesFound = Directory.GetFiles(currentDirectory, "*.*proj", SearchOption.TopDirectoryOnly);
                if (filesFound.Length != 0)
                    break;

                // If we find a solution file, stop.
                filesFound = Directory.GetFiles(currentDirectory, "*.sln", SearchOption.TopDirectoryOnly);
                if (filesFound.Length != 0)
                    break;

                // If we're at the root, stop.
                if (currentDirectory == string.Empty || currentDirectory == Path.GetPathRoot(path))
                    break;

                // Else we move one directory up.
                currentDirectory = currentDirectory.Remove(currentDirectory.Length - 1); // Remove last Path.DirectorySeparatorChar
                currentDirectory = currentDirectory.Substring(0, currentDirectory.LastIndexOf(Path.DirectorySeparatorChar) + 1);
            }
            while (true);

            if (configFiles.Count == 0)
                throw new Exception(string.Format("No project configuration found from '{0}'.", path));

            return configFiles.ToArray();
        }

        /// <summary>
        /// Opens the specified configuration file as a Configuration object. 
        /// </summary>
        /// <param name="fileName">The configuration file name.</param>
        /// <returns>
        /// Returns null if the file is not found or not accessible because of security.
        /// </returns>
        protected System.Configuration.Configuration OpenMappedConfiguration(string fileName)
        {
            // Validate parameters.
            if (string.IsNullOrEmpty(fileName))
                throw new ArgumentNullException("fileName");

            try
            {
                if (_excludedFiles.ContainsKey(fileName))
                    return null;

                ExeConfigurationFileMap fileMap = new ExeConfigurationFileMap();
                fileMap.ExeConfigFilename = fileName; // Assumes already set to full path.

                System.Configuration.Configuration cfg = ConfigurationManager.OpenMappedExeConfiguration(fileMap, ConfigurationUserLevel.None);

                return cfg.HasFile ? cfg : null;
            }
            catch (SecurityException exception) 
            {
                _excludedFiles.Add(fileName, exception);
                Trace.WriteLine(string.Format("Lookup for file '{0}' failed. The process will try to locate another valid configuration file. The reason for the error was: '{1}'.", fileName, exception.Message), "SubSonic.ConfigurationResolver");
                return null;
            }
        }

        /// <summary>
        /// Gets the assembly configuration file name for the given assembly.
        /// </summary>
        /// <param name="assembly">The assembly to get the configuration file name for.</param>
        /// <returns>The assembly configuration file.</returns>
        protected string GetAssemblyConfigFileName(Assembly assembly)
        {
            // Validate parameters.
            if (assembly == null)
                throw new ArgumentNullException("assembly");

            string fileName = string.Format("{0}.config", Path.GetFileNameWithoutExtension(assembly.CodeBase));

            return GetFullPath(fileName);
        }

        /// <summary>
        /// Initializes the configuration file overrides.
        /// </summary>
        /// <param name="fileNames">The list of configuration file overrides.</param>
        protected void InitConfigFileOverrides(string[] fileNames)
        {
            // Validate parameters.
            if (fileNames == null)
                throw new ArgumentNullException("fileNames");
            if (fileNames.Length == 0)
                throw new ArgumentException("Array empty.", "fileNames");

            _configFileOverrides = new string[fileNames.Length];
            for (int iFileName = 0; iFileName < fileNames.Length; iFileName++)
            {
                if (string.IsNullOrEmpty(Path.GetFileName(fileNames[iFileName])))
                    throw new ArgumentOutOfRangeException("fileNames");
                _configFileOverrides[iFileName] = GetFullPath(fileNames[iFileName]);
            }
        }

        /// <summary>
        /// Gets the full path for a given file name.
        /// </summary>
        /// <param name="fileName">The file name to get the full path for.</param>
        /// <returns>The full path of the file.</returns>
        protected string GetFullPath(string fileName)
        {
            if (string.IsNullOrEmpty(Path.GetDirectoryName(fileName)))
            {
                string appBase = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;
                if (!appBase.EndsWith(Path.DirectorySeparatorChar.ToString()))
                    appBase += Path.DirectorySeparatorChar;
                return string.Format("{0}{1}", appBase, fileName);
            }
            else
                return fileName;
        }

        #endregion Methods
    }
}
